跳到主要内容

纯 ESM 包:现代 JavaScript 模块化指南

· 阅读需 19 分钟
Random Image
图片与正文无关
  • 原文链接: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c
  • 机器翻译: Gemini 2.5 Pro Preview
  • 提示词: 翻译以下文档,并且结合你的前端开发专业知识补充相关的知识点说明,对文章内容进行结构化,以 Markdown 格式返回,文字风格是技术博客。
  • 翻译理由:越来越多的库开发者讲自己的包直接打包成纯 ESM 形式,并且指向这个文档,让库使用者也尽快迁移到 ESM。

核心问题:无法再使用 require()

关键点:一个 Pure ESM 包不能再通过 CommonJS 的 require() 函数来同步导入。

// ❌ 这种方式在 CommonJS 项目中会报错
// const foo = require('pure-esm-package');

// ✅ 需要改用 ESM 的 import 语法
// import foo from 'pure-esm-package';

这种转变是 JavaScript 生态系统逐步拥抱官方标准模块系统的一部分。虽然带来了阵痛,但长远来看,它统一了前后端的模块规范,带来了诸多好处。

如何应对 Pure ESM 包?

面对这种情况,你有以下几种选择:

  1. 迁移你的项目到 ESM (强烈推荐)

    • 方法:在你的代码中使用 import foo from 'pure-esm-package' 替代 const foo = require('pure-esm-package')
    • 配套措施:需要在 package.json 中添加 "type": "module" 声明,并可能需要调整构建配置、文件扩展名等。下文会详细介绍。
    • 前端视角:现代前端框架(如 Vue 3, React with Vite, SvelteKit, Next.js 12+)原生或更好地支持 ESM。迁移到 ESM 可以更好地利用 Tree Shaking(摇树优化)减少最终打包体积,并与浏览器原生模块加载机制保持一致。
  2. 在 CommonJS 中使用动态 import() (异步上下文)

    • 方法:如果你的代码允许异步操作,可以使用 await import('pure-esm-package') 来加载 ESM 包。
    • 示例:
      // 在 async 函数或顶层 await (Node.js >= 14.8) 中
      async function loadMyModule() {
      const { default: foo } = await import("pure-esm-package");
      // 注意:ESM 默认导出的模块需要通过 .default 访问
      // 如果是命名导出,则: const { namedExport } = await import('pure-esm-package');
      foo.doSomething();
      }
    • 限制:这只能在异步函数或支持顶层 await 的环境中使用。无法用于需要同步返回模块的场景。
    • 前端视角:动态 import() 在前端常用于代码分割(Code Splitting),按需加载路由或组件,以优化首屏加载性能。这里的用法类似,但目的是解决 CJS/ESM 互操作性问题。
  3. 锁定旧版本

    • 方法:暂时停留在该 Pure ESM 包的最后一个 CommonJS 兼容版本。
    • 风险:你将无法获得该包后续的新功能、性能优化和安全修复。这通常只是一个短期缓兵之计。
  4. 尝试 Node.js 22+ 的 require() ESM 支持 (不推荐)

    • 背景:Node.js 22 开始实验性地支持通过 require() 加载 同步 ESM 模块图。
    • 强烈建议官方和社区普遍不推荐依赖此特性。它可能存在性能问题、兼容性陷阱,且违背了 ESM 的设计哲学。迁移到 ESM 才是正途。

最低环境要求:请确保你的 Node.js 版本至少为 v16,强烈推荐使用 Node.js 18 或更高版本,因为许多现代工具和库都已将此作为最低要求,并且对 ESM 的支持更为完善。

核心理念:ESM 可以导入 CommonJS 包(通常没有问题),但 CommonJS 包无法同步 require() ESM 包。这是单向的兼容性。

请注意:作者明确表示,其仓库不是解答通用 ESM、TypeScript、Webpack、Jest、ts-node、Create React App (CRA) 等工具支持问题的场所。请查阅相应工具的官方文档或社区。


FAQ:迁移与实践详解

Q1: 如何将我的 CommonJS 项目迁移到 ESM?

以下是关键步骤:

  1. package.json 设置

    • 添加 "type": "module"。这告诉 Node.js (和许多构建工具) 默认将 .js 文件视为 ESM。
    • "main": "index.js" 替换为 "exports": "./index.js""exports" 字段提供了更精细的包入口点控制,是现代 Node.js 包的标准。
    • 更新 "engines" 字段,建议至少 "node": ">=18"
  2. 代码调整

    • 移除所有文件顶部的 'use strict'; (ESM 默认就是严格模式)。
    • 将所有 require()module.exports / exports.* 替换为 importexport 语法。
    • 使用完整的相对文件路径:导入本地文件时必须包含文件扩展名(通常是 .js,即使源文件是 .ts 编译后也应导入 .js)。例如:import x from './utils'; 需改为 import x from './utils.js';
    • 导入 Node.js 内置模块时,使用 node: 协议前缀,例如:import fs from 'node:fs';。这能明确区分内置模块和第三方模块,并有助于某些场景下的解析。
  3. TypeScript 类型定义 (如果适用)

    • 如果你的包包含类型定义文件 (如 index.d.ts),确保其中的导入/导出语法也更新为 ESM 格式。

扩展阅读:如果你对如何为 JavaScript 包添加类型定义感兴趣,可以参考作者的 TypeScript Definition Style Guide

Q2: 如何在 TypeScript 项目中使用 (或输出) ESM?

是的,完全可以!你需要配置 TypeScript 项目以输出 ESM 格式的代码。

关键步骤

  1. TypeScript 版本:确保使用 TypeScript 4.7 或更高版本。
  2. package.json 配置
    • 添加 "type": "module"
    • "main" 替换为 "exports" (同上)。
    • 更新 "engines" 至 Node.js 18+ (同上)。
  3. tsconfig.json 配置
    • 设置 "module": "node16""module": "nodenext"非常重要,这指示 TypeScript 生成与 Node.js ESM 兼容的 JavaScript 代码。
    • 设置 "moduleResolution": "node16""moduleResolution": "nodenext"同样重要,这告诉 TypeScript 编译器如何查找模块,使其行为与 Node.js 的 ESM 解析规则一致。绝对不能设为 "node"
    • 示例配置参考:sindresorhus/tsconfig
  4. 代码调整
    • 必须使用 .js 扩展名进行相对导入,即使你实际导入的是 .ts 文件。TypeScript 会在编译时正确处理它们。import util from './util'; -> import util from './util.js';
    • 移除 namespace 用法,改用 export
    • 使用 node: 协议导入 Node.js 内置模块。

关于 ts-node:如果使用 ts-node 直接运行 TS 代码,需要遵循 ts-node ESM 指南。参考配置:Example config。(推荐使用 tsx 作为替代品)

Q3: Electron 中如何使用 ESM?

Electron 从版本 28 开始支持 ESM。请参考 Electron ESM 官方文档

Q4: Webpack 构建遇到 ESM 问题怎么办?

问题很可能出在 Webpack 本身或你的 Webpack 配置上。

  1. 确保你使用的是最新版本的 Webpack。
  2. 检查你的 webpack.config.js 是否正确处理了 .js / .mjs 文件以及 package.json 中的 "type": "module"。可能需要调整 resolvemodule.rules 等配置。
  3. 不要 在原作者的仓库提问。尝试在 Stack Overflow 提问或在 Webpack 的 GitHub 仓库 提交 issue。

前端补充:Webpack 5 对 ESM 有了更好的原生支持,但与 CJS 混用、依赖项的模块格式、以及 loader/plugin 的兼容性仍可能引发问题。确保 experiments.outputModule (如果需要输出 ESM bundle) 或 module.rules 中对 .mjs.js (在 "type": "module" 项目中) 的处理是正确的。Vite 等基于原生 ESM 的构建工具通常能更顺畅地处理 ESM 依赖。

Q5: Next.js 构建遇到 ESM 问题怎么办?

升级到 Next.js 12 或更高版本。Next.js 12 引入了对 ESM 的全面支持,包括 npm 包和 URL Imports。

Q6: Jest 测试遇到 ESM 问题怎么办?

Jest 对 ESM 的支持仍在发展中。

  1. 阅读 Jest 官方 ESM 文档
  2. 你可能需要:
    • 在 Node.js 运行时添加 --experimental-vm-modules 标志 (通过 NODE_OPTIONS 环境变量)。
    • 使用 jest-environment-node 或自定义环境。
    • 配置 transform 选项来处理 ESM 语法(如果需要转译)或设置为空对象 {} 以禁用 CommonJS 转换。
    • 确保你的 package.json 设置了 "type": "module"

前端补充:Vitest 是一个基于 Vite 的现代测试框架,它原生支持 ESM,并且配置通常比 Jest 更简单,可以作为 Jest 的替代方案。

Q7: TypeScript + ESM 遇到的其他问题?

再次确认:

  1. package.json 中有 "type": "module"
  2. tsconfig.json 中设置了 "module": "node16" (或 nodenext)。
  3. 所有本地文件的导入语句都使用了 .js 扩展名。

Q8: ts-node + ESM 遇到的问题?

推荐替代品:考虑使用 tsx,它提供了更好的 ESM 支持和性能。

如果仍要使用 ts-node,请确保是最新版本,并遵循 这个指南示例配置

Q9: Create React App (CRA) 遇到 ESM 问题怎么办?

CRA 对 Pure ESM 包的支持还不完善。已知问题如 #10933

  • 建议:向 CRA 仓库报告你遇到的具体问题。
  • 前端补充:CRA 的底层构建工具 (Webpack) 和配置可能没有完全跟上 ESM 的步伐。对于新项目或需要更好 ESM 支持的项目,可以考虑使用 Vite + React 模板,Vite 对 ESM 的原生支持使其处理这类依赖更加顺畅。

Q10: 如何在 ESM 项目中使用 TypeScript 和 AVA 进行测试?

遵循 AVA 官方 TypeScript 指南 (针对 type: module 包)

Q11: 如何确保不意外使用 CommonJS 特有的写法?

使用 ESLint 规则来强制执行 ESM 最佳实践:

Q12: ESM 中没有 __dirname__filename,怎么办?

使用 import.meta.url 来获取当前模块的 URL。

  • Node.js 20.11+ / 21.2+:

    • 可以直接使用 import.meta.dirnameimport.meta.filename
  • 旧版 Node.js:

    import { fileURLToPath } from "node:url";
    import path from "node:path";

    // 获取当前文件的绝对路径
    const __filename = fileURLToPath(import.meta.url);
    // 获取当前文件所在目录的绝对路径
    const __dirname = path.dirname(__filename); // 或者 path.dirname(fileURLToPath(import.meta.url))
  • 更常用的模式 (构造相对于当前模块的路径):

    import { fileURLToPath } from "node:url";

    // 获取同目录下 foo.js 的文件系统路径
    const fooPath = fileURLToPath(new URL("foo.js", import.meta.url));
  • 许多 Node.js API 直接接受 URL 对象:

    // 直接创建 URL 对象,可能可以直接传递给某些 API (如 fs.readFile)
    const fooUrl = new URL("foo.js", import.meta.url);
    // await fs.readFile(fooUrl); // 示例

前端补充:在浏览器环境中,import.meta.url 同样可用,它返回的是模块的 URL。但在前端构建打包后,这个值可能指向 blob: URL 或打包后的文件路径,其行为可能与 Node.js 环境不完全一致。通常在前端代码中直接操作文件系统的场景较少,更多是处理相对资源的 URL。

Q13: 测试时如何导入模块并绕过缓存?

在 ESM 中,没有像 CommonJS delete require.cache[modulePath] 那样标准的、简单的方法来清除缓存。

  • 临时方案 (仅限测试,有内存泄漏风险!): 通过给导入路径添加动态查询参数来强制重新加载。

    const importFresh = async (modulePath) => {
    // 添加时间戳作为查询参数,欺骗模块加载器认为是不同的模块
    return import(`${modulePath}?t=${Date.now()}`);
    };

    // 使用示例
    const chalk = (await importFresh("chalk")).default;

    警告:

    1. 这种方法会导致内存泄漏,因为旧模块实例不会被垃圾回收。绝对不要在生产环境中使用
    2. 它只会重新加载你直接导入的那个模块,不会重新加载其依赖项。
  • 未来:Node.js 的 ESM Loader Hooks 成熟后可能会提供更好的解决方案。

前端补充:在浏览器端,模块缓存由浏览器管理。测试框架(如 Vitest)通常有自己的模块模拟(mocking)和隔离机制,不依赖这种 hacky 的缓存清除方式。

Q14: 如何导入 JSON 文件?

  • Node.js 17.5+ (需要 --experimental-json-modules 标志) / Node.js 18.20+ (稳定):

    • 使用 Import Assertions (导入断言):
      import packageJson from './package.json' with { type: 'json' };
      console.log(packageJson.version);
  • 旧版 Node.js 或不使用实验性特性: * 使用 fs 模块读取文件并手动解析:

    import fs from 'node:fs/promises';

    const packageJsonContent = await fs.readFile('./package.json', 'utf8');
    const packageJson = JSON.parse(packageJsonContent);
    console.log(packageJson.version);
    ```

    **前端补充**:现代前端构建工具(Webpack, Vite, Rollup 等)通常内置了对 JSON 导入的支持,你可以在代码中直接 `import data from './data.json';`,构建工具会负责处理。Import Assertions 是更标准化的方式,未来可能会被构建工具更广泛地原生支持。

Q15: 何时使用默认导出 (default export) vs 命名导出 (named exports)?

这是一个风格和实践问题,作者给出了他的建议:

  • 默认导出 (export default):

    • 适用于一个模块主要导出一个核心功能、类或对象时。

    • 例如:一个 left-pad 库主要就是那个填充函数。

    • 可以与命名导出结合使用:

      // read-json.js
      export default function readJson() {
      /* ... */
      }
      export class JSONError extends Error {
      /* ... */
      }

      // usage.js
      import readJson, { JSONError } from "read-json";
  • 命名导出 (export { ... }export const/function/class ...):

    • 多个主要 API: 如果一个包提供多个同等重要的功能,特别是包含同步和异步版本时,使用命名导出更清晰。

      // read-json.js
      export function readJson() {
      /* async */
      }
      export function readJsonSync() {
      /* sync */
      }

      // usage.js
      import { readJson, readJsonSync } from "read-json"; // 清晰区分
    • 避免模糊命名: 避免使用过于通用的名称作为命名导出,这会迫使消费者重命名以防冲突。

      // ❌ 不好的例子: parse-json.js
      // export function parse() { ... }

      // 消费者需要重命名
      // import { parse as parseJson } from 'parse-json';

      // ✅ 好的例子: parse-json.js
      export function parseJson() { ... }

      // 消费者直接使用
      import { parseJson } from 'parse-json';
    • 取代命名空间模式: ESM 中,倾向于使用描述性的命名导出,而不是像 CommonJS 那样导出一个包含多个方法的对象(命名空间)。

      // CommonJS (旧)
      // const isStream = require('is-stream');
      // isStream.writable(stream);

      // ESM (推荐)
      // is-stream.js
      // export function isStream() { ... }
      // export function isReadableStream() { ... }
      // export function isWritableStream() { ... }

      // usage.js
      import { isWritableStream } from "is-stream";
      isWritableStream(stream);

前端补充

  • Tree Shaking: 命名导出通常对 Tree Shaking 更友好。构建工具能更容易地静态分析出哪些命名导出被使用了,从而移除未使用的代码。默认导出如果是对象或类,其内部方法的摇树优化可能需要更复杂的分析或特定写法。
  • 组件库: 大型组件库通常使用命名导出来导出各个组件,方便用户按需导入:import { Button, Modal } from 'my-ui-library';
  • 可读性与可发现性: 命名导出使得模块提供的功能更加一目了然,IDE 也能提供更好的自动补全提示。

总结

转向 Pure ESM 是 JavaScript 生态系统发展的必然趋势。虽然短期内会给使用 CommonJS 的项目带来一些挑战,但理解 ESM 的基本原理、掌握迁移步骤和解决常见问题的策略至关重要。积极拥抱 ESM 不仅能让你使用最新的库和工具,还能享受到模块标准化、性能优化(如更好的 Tree Shaking)和与浏览器环境更一致的开发体验。对于前端开发者而言,熟悉 ESM 更是现代 Web 开发的基础。